线程基础

作者:[美]易格恩.阿格佛温(Eugene Agafonov)
译者:黄博文 黄辉兰
改编:陈广
日期:2018-3-13


本章讲解使用最原始的方法创建和使用线程(Thread),本章所涉及的内容已经过时或淘汰。我还是坚持把它写出来是因为这是最好的理解线程的方式,也是理解后面内容的一个基础,另外线程池也要求多线程继续存在。当然Thread我只讲我认为有必要了解的一部分,而不会所有内容都讲完。

简介

早先,计算机一般只有一个核心,而不是现在的多个核心。但是早期的计算机是可以同时运行多个应用程序的,即实现了多任务的概念。我们生活中无处不在的单片机虽然相对CPU没有那么复杂、高速,但也可以同时执行多个任务,简单的多任务可以通过中断来完成,但如果任务复杂到一定程度,并且同时执行的任务数量多时,就需要使用到操作系统了。操作系统的最主要任务,其实就是协调多个任务或程序的同时运行。

不同的操作系统同时执行多个任务的方式也各不相同,如实时操作系统,主要通过优先级来协调程序的运行,高优先级任务会中断低优先级任务,从而保证高优先级任务会得到即时响应。我们个电脑所使用的操作系统则是非实时操作系统,他把CPU时间划分为一个个片段,每个程序执行一个片段,然后轮到下一个程序执行,虽然也有优先级,但优先级的高低只是影响能分配到的时间的长短。

个人电脑操作系统中有进程和线程的概念,这点需要弄清楚。可以这样理解,一个程序表示一个进程,而一个进程里面还可以包含多个线程。也就是一个操作系统可以同时执行多个程序(进程),而一个程序(进程)里还可以同时执行多个线程。

本章中的内容将关注于使用C#语言执行一些非常基本的线程操作。我们将介绍线程的生命周期,其包括创建线程、挂起线程、线程等等以及中止线程。

使用C#创建线程

这里我们使用vscode中的控制台来编写程序,vscode的安装下载及使用请参考《.NET Core 2.0开发环境安装》这篇文章。

新建一个ThreadBasic文件夹,在此文件夹上点鼠标右键,选择Open with Code,从而打开Visual Studio Code,并定位到此文件夹。使用dotnet new console创建一个新的控制台应用程序,输入如下代码:

using System;
using System.Threading;

namespace ThreadBasic
{
    class Program
    {
        static void PrintNumbers()
        {
            Console.WriteLine("开始......");
            for(int i=1;i<10;i++)
            {
                Console.WriteLine(i);
            }
        }
        static void Main(string[] args)
        {
            Thread t=new Thread(PrintNumbers);
            t.Start();
            PrintNumbers();  
        }
    }
}

运行结果:

在这个程序中,我们写了一个PrintNumbers()方法,此方法用循环打印数字1~10。然后在Main()函数中创建了这个方法所对应的线程并启动。最后在主线程中再次调用PrintNumbers()

提示:
正在执行中的程序实例可被称为一个进程。进程由一个或多个线程组成。这意味着当运行程序时,始终有一个执行程序代码的主线程。可以这么理解,主线程从main()函数开始执行,除非你在代码中创建了其他线程,要不所有代码都在主线程中执行。

上例中,我们就创建了一个线程,先记下创建线程三步曲:

  1. 编写一个方法,把线程所要执行的代码放在里面。
  2. 调用new Thread()来创建一个线程,并将方法名称当作参数传递进行。
  3. 调用方法实例的Start()方法来启动线程。

来分析一下结果,第一个开始是主线程中的PrintNumbers()所打印出来的,第二个开始是线程t打印出来的。按道理来说,两者应该同时执行、交替打印,但由于现在的电脑速度太快,线程t还未创建完毕,主线程中的PrintNumbers()已经执行完了。要想看到它们同时执行,只需每打印一个数字后延时一小段时间即可。更改代码如下:

static void PrintNumbers()
{
    Console.WriteLine("开始......");
    for(int i=1;i<10;i++)
    {
        Console.WriteLine(i);
        Thread.Sleep(200); //加上这一句
    }
}
static void Main(string[] args)
{
    Thread t=new Thread(PrintNumbers);
    t.Start();
    PrintNumbers();  
}

运行结果:

Thread.Sleep()方法让当前所处线程暂停一定时间,也可以说是让线程休眠,它会占用尽可能少的CPU时间。当一个线程暂停,出让了控制权,自然而然,另一个线程就会接手控制权。所以我们会看到上述结果中,两个线程交替打印数字,因为它们每打印一个数字就会休息一会。

线程等待

有时,我们需要等待一个线程结束后才能做一些特定事情。将上例稍作更改:

static void PrintNumbers()
{
    Console.WriteLine("开始......");
    for (int i = 1; i <= 5; i++)
    {
        Console.WriteLine(i);
        Thread.Sleep(200);
    }
}
static void Main(string[] args)
{
    Thread t = new Thread(PrintNumbers);
    t.Start();
    Console.WriteLine("线程结束!");
}

运行结果:

可以看到,线程才刚开始没多久,就已经打印线程结束了。我们希望在线程结束运行之后再打印线程结束,更改Main()函数代码如下:

static void Main(string[] args)
{
    Thread t = new Thread(PrintNumbers);
    t.Start();
    t.Join(); //加上这一句
    Console.WriteLine("线程结束!");
}

运行结果:

现在再来看,直到线程运行结束,才打印线程结束。

我们在主程序中调用了t.Join()方法,该方法允许我们等待直到线程t完成。当线程t完成后,主程序才会继续运行。借助该技术可以实现在两个线程间同步执行步骤。第一个线程会等待另一个线程完成后再继续执行。第一个线程等待时处于阻塞状态。

终止线程

有一个Thread.Abort方法可用于关闭线程,但使用这种方法非常危险,且不一定能终止线程,因此不推荐使用这个方法。.NET Framework最新版本为了兼容旧程序虽然还支持这个方法,但机制已变。而.NET Core则已经完全不支持使用这个方法来终止线程了。正确终止线程的方法我们后面再讨论。

##前台线程和后台线程
更改代码如下:

static void PrintNumbers()
{
    Console.WriteLine("开始......");
    for (int i = 1; i <= 10; i++)
    {
        Console.WriteLine(i);
        Thread.Sleep(1000);
    }
}
static void Main(string[] args)
{
    Thread t = new Thread(PrintNumbers);
    t.Start();
    Thread.Sleep(5000);
}


结果和之前所有程序一样,直到t线程结束,程序才结束运行,这里主线程睡眠5秒不会对程序产生任何影响。默认情况下一个线程是前台线程,这意味着程序会等待所有前台线程结束运行后才会关闭。如果是后台线程会是个什么情况呢?

更改Main()函数如下:

static void Main(string[] args)
{
    Thread t = new Thread(PrintNumbers);
    t.IsBackground = true; //新添加语句
    t.Start();
    Thread.Sleep(5000);
}


我们把线程的IsBackground属性设置为true,使得线程t变为后台线程。从运行结果可知,主线程睡眠5秒后程序运行结束,线程t也随之被关闭。

总结一下就是:进程会等待所有的前台线程完成后再结束工作,但是如果只剩下后台线程,则会直接结束工作。一个重要注意事项是如果程序定义了一个不会完成的前台线程,主程序并不会正常结束。

向线程传递参数

向Thread传递参数有几种方法,下面一一介绍。

通过对象构造函数传递参数

namespace ThreadBasic
{
    class ThreadSample
    {
        private readonly int _iterations;
        public ThreadSample(int iterations)
        {
            _iterations = iterations;
        }
        public void CountNumbers()
        {
            for (int i = 1; i <= _iterations; i++)
            {
                Thread.Sleep(500);
                Console.WriteLine(i);
            }
        }
    }
    class Program
    {
        static void Main(string[] args)
        {   //创建线程所在类实例,并传递数字5
            var sample = new ThreadSample(5);
            //创建线程
            var threadOne = new Thread(sample.CountNumbers);
            threadOne.Start();
        }
    }
}

运行结果:

这一次,我们将线程方法包装在了一个类里面,并通过类的构造函数传递一个参数,以决定线程内部的循环次数。传递数字5,最终线程打印了1~5。这种传递参数的方法比较麻烦,还要为线程专门构建一个类。

通过Thread.Start()方法传递参数

更改代码如下:

static void Main(string[] args)
{
    var t = new Thread(CountNumbers);
    t.Start(5); //参数在此传递
}
static void CountNumbers(object iterations)
{   //将参数强制转换为int
    int iter = (int)iterations;
    for (int i = 1; i <= iter; i++)
    {
        Thread.Sleep(500);
        Console.WriteLine(i);
    }
}

运行结果同上。

这次使用Thread.Start方法来传递参数。这种方法看似简便,但也相当麻烦。Start()方法只接收object类型参数,并且只接收一个。为了适应它,线程方法的参数也必须声明为object类型。在处理参数之前,必须先进行强制类型转换。

通过lambda表达式传递参数

更改代码如下:

static void Main(string[] args)
{
    var t = new Thread(() => CountNumbers(5));
    t.Start();
}
static void CountNumbers(int iterations)
{
    for (int i = 1; i <= iterations; i++)
    {
        Thread.Sleep(500);
        Console.WriteLine(i);
    }
}

运行结果同上。

这次就很完美了,线程方法和平常一样声明,只需要创建线程时使用lambda表达式即可,优美,简单!我们来试试是否可以传递多个参数。

更改代码如下:

static void Main(string[] args)
{
    var t = new Thread(() => CountNumbers("Superman", 5));
    t.Start();
}
static void CountNumbers(string name, int iterations)
{
    for (int i = 1; i <= iterations; i++)
    {
        Thread.Sleep(500);
        Console.WriteLine(name + ":" + i);
    }
}

运行结果:

传递多个参数没有任何问题。

通过lambda表达式的闭包方式传递参数

上例() => CountNumbers(5)中,我们直接传递整形常量5,那么是不是可以把常量改为局部变量呢?

更改代码如下:

static void Main(string[] args)
{
    int i = 5;
    var t = new Thread(() => CountNumbers(i));
    t.Start();
}
static void CountNumbers(int iterations)
{
    for (int i = 1; i <= iterations; i++)
    {
        Thread.Sleep(500);
        Console.WriteLine(i);
    }
}

运行结果:

要理解我现在要讲的问题,请先参考《Lambda表达式 - 委托进化史》这篇文章。

本质上() => CountNumbers(i)是独立于Main()方法的另一个方法。而在另一个方法中是无法访问Main()方法中的局部变量i的。但从程序结果可知,局部变量i成功传递给了lambda表达式。这种在一个方法中访问另一方法中的局部变量的行为方式被称为闭包。从感观上来说() => CountNumbers(i)存在于Main()方法之中,访问Main()的局部变量合乎常理。但从原理上来讲,两者属于不同的作用域,不能互相访问。可以访问,我们写起程序来当然会方便很多,闭包的实现也是编译器在后台做了一些工作,从而自动帮我们实现的结果。

但需要注意的是,闭包很美丽,但往往会有陷阱。如果一个局部变量同时传递给不同的线程,有可能会出现意想不到的结果。

static void Main(string[] args)
{   //线程1
    int i = 5;
    var t1 = new Thread(() => CountNumbers(i));
    t1.Start();
    //线程2
    i = 10;
    var t2 = new Thread(() => CountNumbers(i));
    t2.Start();
}
static void CountNumbers(int iterations)
{
    Console.WriteLine(iterations);
}

运行结果:

在线程t1中,我们传递进去的明明是5,为什么会打印出10呢?这是因为在t1在访问局部变量i之前,i的值已经被主线程改变为10了。

lock关键字

使用lock锁定资源

本节将描述如何确保当一个线程使用某些资源时,同时其他线程无法使用该资源。使用如下代码:

class Counter
{
    public int Count { get; private set; }
    public void Increment()
    {
        Count++;
    }
    public void Decrement()
    {
        Count--;
    }
}
class Program
{
    static void Main(string[] args)
    {
        Console.WriteLine("Incorrect counter");
        var c = new Counter();
        var t1 = new Thread(() => TestCounter(c));
        var t2 = new Thread(() => TestCounter(c));
        var t3 = new Thread(() => TestCounter(c));
        t1.Start();
        t2.Start();
        t3.Start();
        t1.Join();
        t2.Join();
        t3.Join();

        Console.WriteLine("Total count: {0}", c.Count);
    }
    static void TestCounter(Counter c)
    {
        for (int i = 0; i < 100000; i++)
        {
            c.Increment();
            c.Decrement();
        }
    }
}

本例我们开三个线程同时操作计数器类Counter,所做的操作很简单,仅是对它的Count属性加1后再减1。也就是说,每次操作过后,Count属性值都应该保持为0,最终结果也应该为0。

但很遗憾,我这里第一次运行结果为50,第二次再运行,结果为30。结果不但不为0,还不确定。请参照下图脑补以下场景:

上图中纵向箭头为时间轴,横向箭头代表事件,依照其位置高低依次发生。

当多个线程同时访问counter对象时,t1得到Counter值为0并增加为1。然后t2得到的值是1并增加为2。t1得到Counter值为2,但在递减操作发生之前,t2线程得到的Counter值也是2。然后t1将2递减为1并保存回Counter中,同时t2也将2递减为1并保存回Counter中。最终Counter值变为了1,从而出现错误。这种情形被称为竞争条件(race condition)。竞争条件是多线程环境中非常常见的导致错误的原因。

为确保不会发生以上情形,必须保证当有线程操作Counter对象时,所有其他线程必须等待直到当前线程完成操作。我们可以使用lock关键字来实现这种行为。如果锁定了一个对象,需要访问该对象的所有其他线程则会处于阻塞状态,并等待直到该对象解除锁定。当然这也可能会导致严重的性能问题,我们会在后面章节进行讨论。

现在我们给Counter加把锁,将Counter类代码修改如下:

class Counter
{
    private readonly object _syncRoot = new object(); 
    public int Count { get; private set; }
    public void Increment()
    {
        lock (_syncRoot)
        {
            Count++;
        }
    }
    public void Decrement()
    {
        lock (_syncRoot)
        {
            Count--;
        }
    }
}

现在运行程序,就可以得到正确结果了。需要注意的是,lock并不是用于Counter对象,而是单独声明一个变量来lock。

死锁(deadlock)

死锁是指两个或两个以上的进程在执行过程中,由于竞争资源或者由于彼此通信而造成的一种阻塞的现象,若无外力作用,它们都将无法推进下去。此时称系统处于死锁状态或系统产生了死锁,这些永远在互相等待的进程称为死锁进程。打个比方,朝鲜跟美国说:“你必须先解除制裁我才弃核”;而美国回答说:“你必须先弃核我才解除制裁”。如果双方都不愿先让一步,结果就是局面僵死在那了。

static void Main(string[] args)
{
    object lock1 = new object();
    object lock2 = new object();
    new Thread(() => LockTooMuch(lock1, lock2)).Start();
    lock (lock2)
    {
        Console.WriteLine("局面僵持");
        Thread.Sleep(1000);
        lock (lock1)
        {
            Console.WriteLine("双方和解");
        }
    }
}
static void LockTooMuch(object lock1, object lock2)
{
    lock (lock1)
    {
        Thread.Sleep(1000);
        lock (lock2){};
    }
}

运行结果:
局面僵持

我们来看看上面程序的执行过程

  1. 线程LockTooMuch锁定lock1后休眠一秒
  2. 主线程锁定lock2,然后休眠一秒
  3. 线程LockTooMuch睡醒后妄图给lock2上锁,但现在lock2正由主线程锁定中,只能阻塞等待中
  4. 主线程睡醒后妄图给lock1上锁,但现在lock1正由LockTooMuch锁定中,只能阻塞等待

最后结果:线程LockTooMuch和主线程互相等待对方先释放自己的资源,局面僵持。

要解决这个问题,可以使用Monitor类。更改代码如下:

static void Main(string[] args)
{
    object lock1 = new object();
    object lock2 = new object();
    new Thread(() => LockTooMuch(lock1, lock2)).Start();
    lock (lock2)
    {
        Console.WriteLine("局面僵持");
        Thread.Sleep(1000);
        if(Monitor.TryEnter(lock1,TimeSpan.FromSeconds(5)))
        {
            Console.WriteLine("主线程胜利");
        }
        else
        {
            Console.WriteLine("主线程让步");
        }
    }
}
static void LockTooMuch(object lock1, object lock2)
{
    lock (lock1)
    {
        Thread.Sleep(1000);
        lock (lock2){};
        Console.WriteLine("LockTooMuch线程胜利");
    }
}

运行结果:

Monitor.TryEnter()方法,使得主线程锁定lock1 5秒钟,5秒钟还未见lock1释放则放弃锁定,返回false。从程序运行结果可知,由于主线程的让步,最终LockTooMuch获得资源。

处理异常

本节讲述了在线程中如何正确地处理异常。在线程中始终使用try/catch代码块是非常重要的,因为不可能在线程代码之外来捕获异常。

执行以下代码:

static void Main(string[] args)
{
    try
    {
        var t = new Thread(BadFaultyThread);
        t.Start();
    }
    catch 
    {
        Console.WriteLine("此处不可达!");
    }
}

static void BadFaultyThread()
{
    Console.WriteLine("开始BadFaultyThread线程...");
    Thread.Sleep(TimeSpan.FromSeconds(2));
    throw new Exception("BadFaultyThread抛出的异常!");
}

运行结果:

程序运行出错,在主线程中未能捕获支线程中所发生的异常。更改代码如下:

static void Main(string[] args)
{
    var t = new Thread(FaultyThread);
    t.Start();
}
static void FaultyThread()
{
    try
    {
        Console.WriteLine("开始FaultyThread线程...");
        Thread.Sleep(TimeSpan.FromSeconds(1));
        throw new Exception("FaultyThread抛出的异常!");
    }
    catch (Exception ex)
    {
        Console.WriteLine("Exception handled: {0}", ex.Message);
    }
}

运行结果:

可以看到,这次正常捕获异常。一般来说,不要在线程中抛出异常,而是在线程代码中使用try/catch代码块。